/** * Model Schema API Endpoint * * Fetches parameter schema for a specific model from its provider. * Returns simplified parameter list for UI rendering. * * GET /api/models/:modelId?provider=replicate|fal|wavespeed * * Headers: * - X-Replicate-Key: Required for Replicate models * - X-Fal-Key: Optional for fal.ai models * - X-WaveSpeed-Key: Optional for WaveSpeed models * * Response: * { * success: true, * parameters: ModelParameter[], * cached: boolean * } * * WaveSpeed models fetch schemas dynamically from the /api/v3/models endpoint, * with fallback to static definitions for models without api_schema. */ import { NextRequest, NextResponse } from "next/server"; import { ProviderType } from "@/types"; import { ModelParameter, ModelInput } from "@/lib/providers/types"; import { getCachedWaveSpeedSchema, setCachedWaveSpeedSchema, WaveSpeedApiSchema, } from "@/lib/providers/cache"; // Cache for model schemas (10 minute TTL) const schemaCache = new Map(); const CACHE_TTL = 10 * 60 * 1000; // 10 minutes // Image input property patterns const IMAGE_INPUT_PATTERNS = [ "image_url", "image_urls", "image", "images", "image_input", "input_image", "first_frame", "last_frame", "tail_image_url", "start_image", "end_image", "reference_image", "init_image", "mask_image", "control_image", ]; // Text input properties const TEXT_INPUT_NAMES = ["prompt", "negative_prompt"]; // Properties that start with "image_" but are NOT image inputs const IMAGE_PREFIX_EXCLUSIONS = ["image_size"]; // Parameters to filter out (internal/system params) const EXCLUDED_PARAMS = new Set([ "webhook", "webhook_events_filter", "sync_mode", "disable_safety_checker", "go_fast", "enable_safety_checker", "output_format", "output_quality", "request_id", ]); // Parameters we want to surface (user-relevant) const PRIORITY_PARAMS = new Set([ "seed", "num_inference_steps", "inference_steps", "steps", "guidance_scale", "guidance", "negative_prompt", "width", "height", "image_size", "num_outputs", "num_images", "scheduler", "strength", "cfg_scale", "lora_scale", ]); interface SchemaSuccessResponse { success: true; parameters: ModelParameter[]; inputs: ModelInput[]; cached: boolean; } interface SchemaErrorResponse { success: false; error: string; } type SchemaResponse = SchemaSuccessResponse | SchemaErrorResponse; /** * Convert property name to human-readable label */ function toLabel(name: string): string { return name .replace(/_url$/, "") .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } /** * Check if property is an image input based on BOTH schema type AND name. * * Image inputs must be strings (URLs or base64) or arrays of strings. * Integers, booleans, numbers with "image" in the name are NOT image inputs. */ function isImageInput(name: string, prop: Record, schemaComponents?: Record): boolean { // First check: must be a string type (images are URLs or base64 strings) // Integers, booleans, numbers are NEVER image inputs regardless of name const resolved = resolvePropertyType(prop, schemaComponents); const propType = resolved.type; if (propType !== "string" && propType !== "array") { return false; } // For arrays, check if items are strings (or unspecified - be lenient) if (propType === "array") { const items = prop.items as Record | undefined; // Only reject if items.type is explicitly specified AND not "string" // Many schemas don't specify items type for image arrays if (items && items.type && items.type !== "string") { return false; } } // Check exclusions (e.g., image_size is a parameter, not an image input) if (IMAGE_PREFIX_EXCLUSIONS.includes(name)) { return false; } // Check format hints (OpenAPI format field or resolved format) - strong signal for image URLs const format = (prop.format ?? resolved.format) as string | undefined; if (format === "uri" || format === "data-uri" || format === "binary") { // Only treat as image if name also suggests it's an image if (IMAGE_INPUT_PATTERNS.includes(name) || name.endsWith("_image") || name.startsWith("image_") || name.includes("_image_")) { return true; } } // Check description for image-related keywords const description = (prop.description as string || "").toLowerCase(); if (description.includes("image url") || description.includes("base64 image") || description.includes("data uri") || description.includes("image file") || description.includes("url of the image") || description.includes("path to image")) { return true; } // Check explicit patterns (exact matches like "image_url", "image") if (IMAGE_INPUT_PATTERNS.includes(name)) { return true; } // More restrictive name pattern matching for strings // Exclude names that suggest counts or settings rather than actual images if (name.includes("_images") || // max_images, num_images name.includes("guidance") || // image_guidance_scale name.includes("generation") || // sequential_image_generation name.includes("_count") || // image_count name.includes("_size") || // image_size (already in exclusions but belt-and-suspenders) name.includes("_scale")) { // image_scale return false; } // Finally, check name patterns for remaining string types return name.endsWith("_image") || name.startsWith("image_") || name.includes("_image_"); } /** * Check if property is a text input */ function isTextInput(name: string): boolean { return TEXT_INPUT_NAMES.includes(name); } /** * Resolve a $ref reference in OpenAPI schema * E.g., "#/components/schemas/AspectRatio" -> schema object */ function resolveRef( ref: string, schemaComponents: Record ): Record | null { // Parse reference path like "#/components/schemas/AspectRatio" const match = ref.match(/^#\/components\/schemas\/(.+)$/); if (!match) return null; const schemaName = match[1]; const resolved = schemaComponents[schemaName] as Record | undefined; return resolved || null; } /** * Resolve the effective type and format from an OpenAPI property. * * Handles wrapper patterns used by code generators (e.g. Pydantic → OpenAPI): * - anyOf / oneOf: picks the first non-null type (nullable pattern) * - allOf: merges referenced schemas * - $ref: resolves from schemaComponents * - Direct type: returns immediately (fast path — no behavior change) */ function resolvePropertyType( prop: Record, schemaComponents?: Record ): { type?: string; format?: string } { // Fast path: direct type is defined — existing behaviour, no change if (prop.type !== undefined) { return { type: prop.type as string, format: prop.format as string | undefined }; } // anyOf / oneOf — pick the first non-null variant const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; if (variants && Array.isArray(variants)) { for (const variant of variants) { // Resolve $ref inside variant if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { const resolved = resolveRef(variant.$ref as string, schemaComponents); if (resolved && resolved.type && resolved.type !== "null") { return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; } } if (variant.type && variant.type !== "null") { return { type: variant.type as string, format: (variant.format ?? prop.format) as string | undefined }; } } } // allOf — merge referenced schemas const allOf = prop.allOf as Array> | undefined; if (allOf && Array.isArray(allOf) && schemaComponents) { for (const item of allOf) { if (item.$ref && typeof item.$ref === "string") { const resolved = resolveRef(item.$ref as string, schemaComponents); if (resolved && resolved.type) { return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; } } if (item.type) { return { type: item.type as string, format: (item.format ?? prop.format) as string | undefined }; } } } // $ref at top level if (prop.$ref && typeof prop.$ref === "string" && schemaComponents) { const resolved = resolveRef(prop.$ref as string, schemaComponents); if (resolved && resolved.type) { return { type: resolved.type as string, format: (resolved.format ?? prop.format) as string | undefined }; } } return {}; } /** * Convert OpenAPI schema property to ModelParameter */ function convertSchemaProperty( name: string, prop: Record, required: string[], schemaComponents?: Record ): ModelParameter | null { // Skip excluded parameters if (EXCLUDED_PARAMS.has(name)) { return null; } // Determine type and extract enum from allOf/$ref/anyOf/oneOf if present let type: ModelParameter["type"] = "string"; let enumValues: unknown[] | undefined; let resolvedDefault: unknown; let resolvedDescription: string | undefined; // Use resolvePropertyType() to handle anyOf/oneOf/allOf/$ref patterns const resolved = resolvePropertyType(prop, schemaComponents); const effectiveType = resolved.type; if (effectiveType === "integer") { type = "integer"; } else if (effectiveType === "number") { type = "number"; } else if (effectiveType === "boolean") { type = "boolean"; } else if (effectiveType === "array") { type = "array"; } // Extract enum/default/description from allOf with $ref const allOf = prop.allOf as Array> | undefined; if (allOf && allOf.length > 0 && schemaComponents) { for (const item of allOf) { const itemRef = item.$ref as string | undefined; if (itemRef) { const refResolved = resolveRef(itemRef, schemaComponents); if (refResolved) { if (Array.isArray(refResolved.enum)) { enumValues = refResolved.enum; } if (refResolved.default !== undefined && resolvedDefault === undefined) { resolvedDefault = refResolved.default; } if (refResolved.description && !resolvedDescription) { resolvedDescription = refResolved.description as string; } } } else if (Array.isArray(item.enum)) { enumValues = item.enum; } } } // Extract enum/default/description from anyOf/oneOf variants const variants = (prop.anyOf ?? prop.oneOf) as Array> | undefined; if (variants && Array.isArray(variants)) { for (const variant of variants) { if (variant.type === "null") continue; // Resolve $ref inside variant if (variant.$ref && typeof variant.$ref === "string" && schemaComponents) { const refResolved = resolveRef(variant.$ref as string, schemaComponents); if (refResolved) { if (Array.isArray(refResolved.enum) && !enumValues) { enumValues = refResolved.enum; } if (refResolved.default !== undefined && resolvedDefault === undefined) { resolvedDefault = refResolved.default; } if (refResolved.description && !resolvedDescription) { resolvedDescription = refResolved.description as string; } } } else { if (Array.isArray(variant.enum) && !enumValues) { enumValues = variant.enum; } if (variant.default !== undefined && resolvedDefault === undefined) { resolvedDefault = variant.default; } } } } const parameter: ModelParameter = { name, type, description: (prop.description as string | undefined) || resolvedDescription, default: prop.default !== undefined ? prop.default : resolvedDefault, required: required.includes(name), }; // Add constraints if (typeof prop.minimum === "number") { parameter.minimum = prop.minimum; } if (typeof prop.maximum === "number") { parameter.maximum = prop.maximum; } // Use enum from property directly, or from resolved $ref if (Array.isArray(prop.enum)) { parameter.enum = prop.enum; } else if (enumValues) { parameter.enum = enumValues; } return parameter; } interface ExtractedSchema { parameters: ModelParameter[]; inputs: ModelInput[]; } /** * Fetch and parse schema from Replicate */ async function fetchReplicateSchema( modelId: string, apiKey: string ): Promise { const [owner, name] = modelId.split("/"); const response = await fetch( `https://api.replicate.com/v1/models/${owner}/${name}`, { headers: { Authorization: `Bearer ${apiKey}`, }, } ); if (!response.ok) { throw new Error(`Replicate API error: ${response.status}`); } const data = await response.json(); // Extract schema from latest_version.openapi_schema const openApiSchema = data.latest_version?.openapi_schema; if (!openApiSchema) { return { parameters: [], inputs: [] }; } // Navigate to Input schema const inputSchema = openApiSchema.components?.schemas?.Input; if (!inputSchema || typeof inputSchema !== "object") { return { parameters: [], inputs: [] }; } // Pass components.schemas for $ref resolution const schemaComponents = openApiSchema.components?.schemas as Record | undefined; return extractParametersFromSchema(inputSchema as Record, schemaComponents); } /** * Fetch and parse schema from fal.ai using Model Search API * Uses: GET https://api.fal.ai/v1/models?endpoint_id={modelId}&expand=openapi-3.0 */ async function fetchFalSchema( modelId: string, apiKey: string | null ): Promise { const headers: Record = {}; if (apiKey) { headers["Authorization"] = `Key ${apiKey}`; } // Use fal.ai Model Search API with OpenAPI expansion const url = `https://api.fal.ai/v1/models?endpoint_id=${encodeURIComponent(modelId)}&expand=openapi-3.0`; const response = await fetch(url, { headers }); if (!response.ok) { // Return empty params if API fails so generation still works return { parameters: [], inputs: [] }; } const data = await response.json(); // Response is { models: [{ openapi: {...}, ... }] } const modelData = data.models?.[0]; if (!modelData?.openapi) { return { parameters: [], inputs: [] }; } const spec = modelData.openapi; // Find POST endpoint with requestBody - paths are keyed by full endpoint path let inputSchema: Record | null = null; for (const pathObj of Object.values(spec.paths || {})) { const postOp = (pathObj as Record)?.post as Record | undefined; const reqBody = postOp?.requestBody as Record | undefined; const content = reqBody?.content as Record> | undefined; const jsonContent = content?.["application/json"]; if (jsonContent?.schema) { const schema = jsonContent.schema as Record; // Handle $ref - resolve from components.schemas if (schema.$ref && typeof schema.$ref === "string") { const refPath = schema.$ref.replace("#/components/schemas/", ""); const resolvedSchema = spec.components?.schemas?.[refPath] as Record | undefined; if (resolvedSchema) { inputSchema = resolvedSchema; break; } } else if (schema.properties) { inputSchema = schema; break; } } } if (!inputSchema) { return { parameters: [], inputs: [] }; } // Pass components.schemas for $ref resolution const schemaComponents = spec.components?.schemas as Record | undefined; return extractParametersFromSchema(inputSchema, schemaComponents); } /** * Extract ModelParameters and ModelInputs from an OpenAPI schema object */ function extractParametersFromSchema( schema: Record, schemaComponents?: Record ): ExtractedSchema { const properties = schema.properties as Record> | undefined; const required = (schema.required as string[]) || []; if (!properties) { return { parameters: [], inputs: [] }; } const parameters: ModelParameter[] = []; const inputs: ModelInput[] = []; for (const [name, prop] of Object.entries(properties)) { // Check if this is a connectable input (image or text) // Pass both name AND prop to check schema type, not just name if (isImageInput(name, prop, schemaComponents)) { const resolvedType = resolvePropertyType(prop, schemaComponents).type; inputs.push({ name, type: "image", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, isArray: resolvedType === "array", }); continue; } if (isTextInput(name)) { inputs.push({ name, type: "text", required: required.includes(name), label: toLabel(name), description: prop.description as string | undefined, isArray: prop.type === "array", }); continue; } // Otherwise it's a parameter const param = convertSchemaProperty(name, prop, required, schemaComponents); if (param) { parameters.push(param); } } // Sort parameters: priority params first, then alphabetically parameters.sort((a, b) => { const aIsPriority = PRIORITY_PARAMS.has(a.name); const bIsPriority = PRIORITY_PARAMS.has(b.name); if (aIsPriority && !bIsPriority) return -1; if (!aIsPriority && bIsPriority) return 1; return a.name.localeCompare(b.name); }); // Sort inputs: required first, then by type (image before text), then alphabetically inputs.sort((a, b) => { if (a.required !== b.required) return a.required ? -1 : 1; if (a.type !== b.type) return a.type === "image" ? -1 : 1; return a.name.localeCompare(b.name); }); return { parameters, inputs }; } /** * Get hardcoded schema for Kie.ai models * Kie.ai doesn't have a schema discovery API, so we define these manually */ function getKieSchema(modelId: string): ExtractedSchema { // Common parameters for image models const imageParams: ModelParameter[] = [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16"], default: "1:1" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ]; // Flux-2 aspect ratios (includes auto and additional ratios) const flux2AspectRatios = ["1:1", "4:3", "3:4", "16:9", "9:16", "3:2", "2:3", "auto"]; // Model-specific schemas const schemas: Record = { // ============ Image models ============ "z-image": { parameters: imageParams, inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "seedream/4.5-text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"], default: "1:1" }, { name: "quality", type: "string", description: "Output quality", enum: ["basic", "high"], default: "basic" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "seedream/4.5-edit": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "4:3", "3:4", "16:9", "9:16", "2:3", "3:2", "21:9"], default: "1:1" }, { name: "quality", type: "string", description: "Output quality", enum: ["basic", "high"], default: "basic" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "gpt-image/1.5-text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2"], default: "3:2" }, { name: "quality", type: "string", description: "Output quality", enum: ["medium", "high"], default: "medium" }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "gpt-image/1.5-image-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2"], default: "3:2" }, { name: "quality", type: "string", description: "Output quality", enum: ["medium", "high"], default: "medium" }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "flux-2/pro-text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "flux-2/pro-image-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "flux-2/flex-text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "flux-2/flex-image-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: flux2AspectRatios, default: "1:1" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K"], default: "1K" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "nano-banana-pro": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["1:1", "2:3", "3:2", "4:3", "16:9", "9:16", "21:9", "auto"], default: "1:1" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["1K", "2K", "4K"], default: "1K" }, { name: "output_format", type: "string", description: "Output format", enum: ["png", "jpg"], default: "png" }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "image_input", type: "image", required: false, label: "Image", isArray: true }, ], }, "grok-imagine/text-to-image": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "1:1" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "grok-imagine/image-to-image": { parameters: [], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, // ============ Audio/TTS models ============ "elevenlabs/turbo-v2.5": { parameters: [ { name: "voice_id", type: "string", description: "Voice ID to use for synthesis" }, { name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 }, { name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 }, { name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Text" }], }, "elevenlabs/multilingual-v2": { parameters: [ { name: "voice_id", type: "string", description: "Voice ID to use for synthesis" }, { name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 }, { name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 }, { name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Text" }], }, "elevenlabs/text-to-dialogue-v3": { parameters: [ { name: "stability", type: "number", description: "Voice stability (0-1)", default: 0.5, minimum: 0, maximum: 1 }, { name: "similarity_boost", type: "number", description: "Similarity boost (0-1)", default: 0.75, minimum: 0, maximum: 1 }, { name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Text / Dialogue Script" }], }, "elevenlabs/sound-effect-v2": { parameters: [ { name: "duration_seconds", type: "number", description: "Duration in seconds (0.5-22)", minimum: 0.5, maximum: 22 }, { name: "loop", type: "boolean", description: "Enable smooth looping", default: false }, { name: "prompt_influence", type: "number", description: "How closely to follow the prompt (0-1)", default: 0.3, minimum: 0, maximum: 1 }, { name: "output_format", type: "string", description: "Audio output format", enum: ["mp3_44100_128", "mp3_44100_192", "pcm_16000", "pcm_22050", "pcm_24000", "pcm_44100"], default: "mp3_44100_128" }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Sound Description" }], }, // ============ Video models ============ "grok-imagine/text-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "2:3" }, { name: "duration", type: "string", description: "Video duration in seconds", enum: ["6", "10"], default: "6" }, { name: "mode", type: "string", description: "Generation mode", enum: ["fun", "normal", "spicy"], default: "normal" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "grok-imagine/image-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["2:3", "3:2", "1:1", "16:9", "9:16"], default: "2:3" }, { name: "duration", type: "string", description: "Video duration in seconds", enum: ["6", "10"], default: "6" }, { name: "mode", type: "string", description: "Generation mode", enum: ["fun", "normal", "spicy"], default: "normal" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "kling-2.6/text-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, { name: "sound", type: "boolean", description: "Enable sound generation", default: true }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "kling-2.6/image-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, { name: "sound", type: "boolean", description: "Enable sound generation", default: true }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "kling-2.6/motion-control": { parameters: [ { name: "mode", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "720p" }, { name: "character_orientation", type: "string", description: "Character orientation source", enum: ["image", "video"], default: "video" }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "input_urls", type: "image", required: true, label: "Image", isArray: true }, { name: "video_urls", type: "image", required: true, label: "Video", isArray: true }, ], }, "kling/v2-5-turbo-text-to-video-pro": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, { name: "cfg_scale", type: "number", description: "Guidance scale", minimum: 0, maximum: 1, default: 0.5 }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" }, ], }, "kling/v2-5-turbo-image-to-video-pro": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16", "1:1"], default: "16:9" }, { name: "duration", type: "string", description: "Video duration", enum: ["5", "10"], default: "5" }, { name: "cfg_scale", type: "number", description: "Guidance scale", minimum: 0, maximum: 1, default: 0.5 }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "negative_prompt", type: "text", required: false, label: "Negative Prompt" }, { name: "image_url", type: "image", required: true, label: "Image" }, { name: "tail_image_url", type: "image", required: false, label: "Tail Image" }, ], }, "wan/2-6-text-to-video": { parameters: [ { name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10", "15"], default: "5" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "wan/2-6-image-to-video": { parameters: [ { name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10", "15"], default: "5" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "image_urls", type: "image", required: true, label: "Image", isArray: true }, ], }, "wan/2-6-video-to-video": { parameters: [ { name: "duration", type: "string", description: "Video duration in seconds", enum: ["5", "10"], default: "5" }, { name: "resolution", type: "string", description: "Output resolution", enum: ["720p", "1080p"], default: "1080p" }, { name: "seed", type: "integer", description: "Random seed for reproducibility", minimum: 0 }, ], inputs: [ { name: "prompt", type: "text", required: false, label: "Prompt" }, { name: "video_urls", type: "image", required: true, label: "Video", isArray: true }, ], }, "topaz/video-upscale": { parameters: [ { name: "upscale_factor", type: "string", description: "Upscale factor", enum: ["1", "2", "4"], default: "2" }, ], inputs: [ { name: "video_url", type: "image", required: true, label: "Video" }, ], }, "veo3/text-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" }, { name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "veo3/image-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" }, { name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "imageUrls", type: "image", required: true, label: "Image", isArray: true }, ], }, "veo3-fast/text-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" }, { name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 }, ], inputs: [{ name: "prompt", type: "text", required: true, label: "Prompt" }], }, "veo3-fast/image-to-video": { parameters: [ { name: "aspect_ratio", type: "string", description: "Output aspect ratio", enum: ["16:9", "9:16"], default: "16:9" }, { name: "seeds", type: "integer", description: "Random seed (10000-99999)", minimum: 10000, maximum: 99999 }, ], inputs: [ { name: "prompt", type: "text", required: true, label: "Prompt" }, { name: "imageUrls", type: "image", required: true, label: "Image", isArray: true }, ], }, }; return schemas[modelId] || { parameters: [], inputs: [] }; } /** * Get static schema for WaveSpeed models (fallback when dynamic schema not available) */ function getStaticWaveSpeedSchema(modelId: string): ExtractedSchema { const modelIdLower = modelId.toLowerCase(); // Common image generation parameters for FLUX, SD3, etc. const imageParams: ModelParameter[] = [ { name: "num_inference_steps", type: "integer", description: "Number of denoising steps. More steps usually lead to higher quality but slower generation.", default: 28, minimum: 1, maximum: 100, }, { name: "guidance_scale", type: "number", description: "Guidance scale for classifier-free guidance. Higher values follow the prompt more closely.", default: 3.5, minimum: 0, maximum: 20, }, { name: "seed", type: "integer", description: "Random seed for reproducibility. Use -1 for random.", default: -1, }, { name: "image_size", type: "string", description: "Output image dimensions", default: "1024x1024", enum: ["512x512", "768x768", "1024x1024", "1024x576", "576x1024", "1024x768", "768x1024", "1280x720", "720x1280"], }, ]; // Image inputs for image-to-image models const imageInputs: ModelInput[] = []; // Video model parameters (WAN, Kling, Luma, etc.) const videoParams: ModelParameter[] = [ { name: "num_frames", type: "integer", description: "Number of frames to generate", default: 81, minimum: 16, maximum: 256, }, { name: "fps", type: "integer", description: "Frames per second for the output video", default: 16, minimum: 8, maximum: 30, }, { name: "seed", type: "integer", description: "Random seed for reproducibility. Use -1 for random.", default: -1, }, { name: "resolution", type: "string", description: "Output video resolution", default: "480p", enum: ["480p", "720p", "1080p"], }, ]; // Check if it's a video model const isVideoModel = modelIdLower.includes("wan") || modelIdLower.includes("video") || modelIdLower.includes("kling") || modelIdLower.includes("luma") || modelIdLower.includes("minimax") || modelIdLower.includes("t2v") || modelIdLower.includes("i2v"); // Check if it's an image-to-image model const isImg2ImgModel = modelIdLower.includes("kontext") || modelIdLower.includes("img2img") || modelIdLower.includes("edit") || modelIdLower.includes("inpaint") || modelIdLower.includes("controlnet"); if (isVideoModel) { // For i2v models, add image input if (modelIdLower.includes("i2v")) { imageInputs.push({ name: "image", // i2v models typically use singular "image" type: "image", required: true, label: "Input Image", description: "Starting image for video generation", }); } return { parameters: videoParams, inputs: imageInputs }; } // Image generation model if (isImg2ImgModel) { imageInputs.push({ name: "images", // WaveSpeed edit models expect "images" (plural array) type: "image", required: true, label: "Input Image", description: "Image to transform or edit", isArray: true, // Signal that this should be sent as an array }); // Add strength parameter for img2img imageParams.push({ name: "strength", type: "number", description: "How much to transform the input image. 0 = no change, 1 = ignore input completely.", default: 0.8, minimum: 0, maximum: 1, }); } return { parameters: imageParams, inputs: imageInputs }; } // WaveSpeed API base URL const WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3"; /** * Fetch WaveSpeed schema dynamically from cache or API * Falls back to static schema if dynamic schema not available */ async function fetchWaveSpeedSchema( modelId: string, apiKey: string | null ): Promise { // First check if we have a cached schema from the models list const cachedSchema = getCachedWaveSpeedSchema(modelId); if (cachedSchema) { console.log(`[WaveSpeed Schema] Using cached schema for ${modelId}`); const result = extractWaveSpeedSchema(cachedSchema, modelId); if (result.parameters.length > 0 || result.inputs.length > 0) { return result; } } // If no cache and we have an API key, try fetching the model directly if (apiKey) { try { console.log(`[WaveSpeed Schema] Fetching schema for ${modelId} from API`); const response = await fetch(`${WAVESPEED_API_BASE}/models`, { headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, }); if (response.ok) { const data = await response.json(); const models = data.models || data.data || data.results || []; // Find the model by ID const model = models.find((m: Record) => { const id = m.model_id || m.id || m.modelId || m.name; return id === modelId; }); if (model?.api_schema) { // Cache the schema for future use setCachedWaveSpeedSchema(modelId, model.api_schema as WaveSpeedApiSchema); const result = extractWaveSpeedSchema(model.api_schema as WaveSpeedApiSchema, modelId); if (result.parameters.length > 0 || result.inputs.length > 0) { console.log(`[WaveSpeed Schema] Found dynamic schema with ${result.parameters.length} params, ${result.inputs.length} inputs`); return result; } } } } catch (error) { console.warn(`[WaveSpeed Schema] Failed to fetch from API: ${error}`); } } // Fall back to static schema console.log(`[WaveSpeed Schema] Using static fallback for ${modelId}`); return getStaticWaveSpeedSchema(modelId); } /** * Extract parameters and inputs from WaveSpeed api_schema * Schema structure: { api_schemas: [{ request_schema: { properties, required } }] } */ function extractWaveSpeedSchema( apiSchema: WaveSpeedApiSchema, modelId: string ): ExtractedSchema { // WaveSpeed schema structure: api_schema.api_schemas[].request_schema const apiSchemas = apiSchema.api_schemas; if (!apiSchemas || !Array.isArray(apiSchemas) || apiSchemas.length === 0) { console.log(`[WaveSpeed Schema] No api_schemas array found for ${modelId}`); return { parameters: [], inputs: [] }; } // Use the first schema (primary request schema) const requestSchema = apiSchemas[0]?.request_schema; if (!requestSchema || typeof requestSchema !== "object") { console.log(`[WaveSpeed Schema] No request_schema found for ${modelId}`); return { parameters: [], inputs: [] }; } // Log the schema structure for debugging const schemaKeys = Object.keys(requestSchema); console.log(`[WaveSpeed Schema] Schema keys for ${modelId}: ${schemaKeys.join(", ")}`); // Extract parameters using the shared extraction function return extractParametersFromSchema(requestSchema as Record); } export async function GET( request: NextRequest, { params }: { params: Promise<{ modelId: string }> } ): Promise> { // Await params before accessing properties const { modelId } = await params; const decodedModelId = decodeURIComponent(modelId); const provider = request.nextUrl.searchParams.get("provider") as ProviderType | null; if (!provider || (provider !== "replicate" && provider !== "fal" && provider !== "kie" && provider !== "wavespeed")) { return NextResponse.json( { success: false, error: "Invalid or missing provider. Use ?provider=replicate, ?provider=fal, ?provider=kie, or ?provider=wavespeed", }, { status: 400 } ); } // Check cache const cacheKey = `${provider}:${decodedModelId}`; const cached = schemaCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL) { return NextResponse.json({ success: true, parameters: cached.parameters, inputs: cached.inputs, cached: true, }); } try { let result: ExtractedSchema; if (provider === "replicate") { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY; if (!apiKey) { return NextResponse.json( { success: false, error: "Replicate API key required. Add REPLICATE_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); } result = await fetchReplicateSchema(decodedModelId, apiKey); } else if (provider === "kie") { // Kie.ai uses hardcoded schemas (no schema discovery API) result = getKieSchema(decodedModelId); } else if (provider === "wavespeed") { // WaveSpeed uses dynamic schemas from API, with static fallback const apiKey = request.headers.get("X-WaveSpeed-Key") || process.env.WAVESPEED_API_KEY || null; result = await fetchWaveSpeedSchema(decodedModelId, apiKey); } else { // User-provided key takes precedence over env variable const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; if (!apiKey) { return NextResponse.json( { success: false, error: "fal.ai API key not configured. Add FAL_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); } result = await fetchFalSchema(decodedModelId, apiKey); } // Cache the result schemaCache.set(cacheKey, { ...result, timestamp: Date.now() }); return NextResponse.json({ success: true, parameters: result.parameters, inputs: result.inputs, cached: false, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error(`[ModelSchema] Error fetching ${decodedModelId}: ${errorMessage}`); return NextResponse.json( { success: false, error: errorMessage, }, { status: 500 } ); } }